Oppdag egenskapbasert testing med Pythons Hypothesis-bibliotek. Gå utover eksempelbaserte tester for å finne edge cases og bygg mer robust og pålitelig programvare.
Utover enhetstester: En dypdykk i egenskapbasert testing med Pythons Hypothesis
I programvareutviklingens verden er testing grunnfjellet for kvalitet. I flere tiår har det dominerende paradigmet vært eksempelbasert testing. Vi lager omhyggelig inndata, definerer de forventede utdataene og skriver påstander for å verifisere at koden vår oppfører seg som planlagt. Denne tilnærmingen, som finnes i rammeverk som unittest
og pytest
, er kraftig og viktig. Men hva om jeg fortalte deg at det finnes en utfyllende tilnærming som kan avdekke feil du aldri engang tenkte å lete etter?
Velkommen til verden av egenskapbasert testing, et paradigme som flytter fokuset fra å teste spesifikke eksempler til å verifisere generelle egenskaper ved koden din. Og i Python-økosystemet er den ubestridte mesteren av denne tilnærmingen et bibliotek som heter Hypothesis.
Denne omfattende veiledningen vil ta deg fra en komplett nybegynner til en selvsikker utøver av egenskapbasert testing med Hypothesis. Vi vil utforske kjernekonseptene, dykke ned i praktiske eksempler og lære hvordan du integrerer dette kraftige verktøyet i din daglige utviklingsarbeidsflyt for å bygge mer robust, pålitelig og feilbestandig programvare.
Hva er egenskapbasert testing? Et skifte i tankesett
For å forstå Hypothesis, må vi først forstå den grunnleggende ideen om egenskapbasert testing. La oss sammenligne det med den tradisjonelle eksempelbaserte testingen vi alle kjenner.
Eksempelbasert testing: Den kjente veien
Tenk deg at du har skrevet en tilpasset sorteringsfunksjon, my_sort()
. Med eksempelbasert testing vil tankeprosessen din være:
- "La oss teste den med en enkel, ordnet liste." ->
assert my_sort([1, 2, 3]) == [1, 2, 3]
- "Hva med en omvendt ordnet liste?" ->
assert my_sort([3, 2, 1]) == [1, 2, 3]
- "Hva med en tom liste?" ->
assert my_sort([]) == []
- "En liste med duplikater?" ->
assert my_sort([5, 1, 5, 2]) == [1, 2, 5, 5]
- "Og en liste med negative tall?" ->
assert my_sort([-1, -5, 0]) == [-5, -1, 0]
Dette er effektivt, men det har en grunnleggende begrensning: du tester bare de tilfellene du kan tenke deg. Testene dine er bare så gode som fantasien din. Du kan gå glipp av edge cases som involverer veldig store tall, unøyaktigheter med flyttall, spesifikke unicode-tegn eller komplekse kombinasjoner av data som fører til uventet oppførsel.
Egenskapsbasert testing: Tenke i invarianter
Egenskapsbasert testing snur manuset. I stedet for å gi spesifikke eksempler, definerer du egenskapene, eller invariantene, til funksjonen din – regler som skal gjelde for enhver gyldig inndata. For vår my_sort()
-funksjon kan disse egenskapene være:
- Utdataene er sortert: For enhver liste med tall er hvert element i utdatalisten mindre enn eller lik det som følger det.
- Utdataene inneholder de samme elementene som inndataene: Den sorterte listen er bare en permutasjon av den opprinnelige listen; ingen elementer legges til eller går tapt.
- Funksjonen er idempotent: Sortering av en allerede sortert liste skal ikke endre den. Det vil si,
my_sort(my_sort(some_list)) == my_sort(some_list)
.
Med denne tilnærmingen skriver du ikke testdataene. Du skriver reglene. Du lar deretter et rammeverk, som Hypothesis, generere hundrevis eller tusenvis av tilfeldige, mangfoldige og ofte lumske inndata for å prøve å bevise at egenskapene dine er feil. Hvis den finner en inndata som bryter en egenskap, har den funnet en feil.
Introduserer Hypothesis: Din automatiserte testdatagenerator
Hypothesis er det fremste biblioteket for egenskapbasert testing for Python. Det tar egenskapene du definerer og gjør det harde arbeidet med å generere testdata for å utfordre dem. Det er ikke bare en tilfeldig datagenerator; det er et intelligent og kraftig verktøy designet for å finne feil effektivt.
Viktige funksjoner i Hypothesis
- Automatisk testtilfellegenerering: Du definerer *formen* på dataene du trenger (f.eks. "en liste med heltall", "en streng som bare inneholder bokstaver", "en datetime i fremtiden"), og Hypothesis genererer et bredt utvalg av eksempler som samsvarer med den formen.
- Intelligent krymping: Dette er den magiske funksjonen. Når Hypothesis finner et mislykket testtilfelle (f.eks. en liste med 50 komplekse tall som krasjer sorteringsfunksjonen din), rapporterer den ikke bare den massive listen. Den forenkler intelligent og automatisk inndataene for å finne det minst mulige eksemplet som fortsatt forårsaker feilen. I stedet for en liste med 50 elementer, kan den rapportere at feilen oppstår med bare
[inf, nan]
. Dette gjør feilsøking utrolig raskt og effektivt. - Sømløs integrasjon: Hypothesis integreres perfekt med populære testrammeverk som
pytest
ogunittest
. Du kan legge til egenskapbaserte tester sammen med dine eksisterende eksempelbaserte tester uten å endre arbeidsflyten din. - Rikt bibliotek med strategier: Den leveres med en enorm samling av innebygde "strategier" for å generere alt fra enkle heltall og strenger til komplekse, nestede datastrukturer, tidssonebevisste datetimes og til og med NumPy-arrays.
- Statlig testing: For mer komplekse systemer kan Hypothesis teste sekvenser av handlinger for å finne feil i statsoverganger, noe som er notorisk vanskelig med eksempelbasert testing.
Komme i gang: Din første Hypothesis-test
La oss skitne til hendene. Den beste måten å forstå Hypothesis er å se den i aksjon.
Installasjon
Først må du installere Hypothesis og testkjøringen du velger (vi bruker pytest
). Det er så enkelt som:
pip install pytest hypothesis
Et enkelt eksempel: En absoluttverdifunksjon
La oss vurdere en enkel funksjon som skal beregne absoluttverdien av et tall. En litt buggy implementering kan se slik ut:
# i en fil som heter `my_math.py` def custom_abs(x): """En tilpasset implementering av absoluttverdifunksjonen.""" if x < 0: return -x return x
La oss nå skrive en testfil, test_my_math.py
. Først, den tradisjonelle pytest
-tilnærmingen:
# test_my_math.py (Eksempelbasert) def test_abs_positive(): assert custom_abs(5) == 5 def test_abs_negative(): assert custom_abs(-5) == 5 def test_abs_zero(): assert custom_abs(0) == 0
Disse testene består. Funksjonen vår ser korrekt ut basert på disse eksemplene. Men la oss nå skrive en egenskapbasert test med Hypothesis. Hva er en kjerneegenskap ved absoluttverdifunksjonen? Resultatet skal aldri være negativt.
# test_my_math.py (Egenskapsbasert med Hypothesis) from hypothesis import given from hypothesis import strategies as st from my_math import custom_abs @given(st.integers()) def test_abs_property_is_non_negative(x): """Egenskap: Absoluttverdien av et hvilket som helst heltall er alltid >= 0.""" assert custom_abs(x) >= 0
La oss bryte dette ned:
from hypothesis import given, strategies as st
: Vi importerer de nødvendige komponentene.given
er en dekoratør som gjør en vanlig testfunksjon om til en egenskapbasert test.strategies
er modulen der vi finner datageneratorene våre.@given(st.integers())
: Dette er kjernen i testen.@given
-dekoratøren forteller Hypothesis å kjøre denne testfunksjonen flere ganger. For hver kjøring vil den generere en verdi ved hjelp av den medfølgende strategien,st.integers()
, og sende den som argumentetx
til testfunksjonen vår.assert custom_abs(x) >= 0
: Dette er egenskapen vår. Vi hevder at uansett hvilket heltallx
Hypothesis finner på, må resultatet av funksjonen vår være større enn eller lik null.
Når du kjører dette med pytest
, vil det sannsynligvis bestå for mange verdier. Hypothesis vil prøve 0, -1, 1, store positive tall, store negative tall og mer. Vår enkle funksjon håndterer alle disse korrekt. La oss nå prøve en annen strategi for å se om vi kan finne en svakhet.
# La oss teste med flyttall @given(st.floats()) def test_abs_floats_property(x): assert custom_abs(x) >= 0
Hvis du kjører dette, vil Hypothesis raskt finne et mislykket tilfelle!
Falsifying example: test_abs_floats_property(x=nan) ... assert custom_abs(nan) >= 0 AssertionError: assert nan >= 0
Hypothesis oppdaget at funksjonen vår, når den får float('nan')
(Ikke et tall), returnerer nan
. Påstanden nan >= 0
er usann. Vi har nettopp funnet en subtil feil som vi sannsynligvis ikke ville ha tenkt å teste for manuelt. Vi kan fikse funksjonen vår for å håndtere dette tilfellet, kanskje ved å heve en ValueError
eller returnere en spesifikk verdi.
Enda bedre, hva om feilen var med et veldig spesifikt flyttall? Hypothesis' krymper ville ha tatt et stort, komplekst mislykket tall og redusert det til den enkleste mulige versjonen som fortsatt utløser feilen.
Kraften i strategier: Lage testdataene dine
Strategier er hjertet i Hypothesis. De er oppskrifter for å generere data. Biblioteket inneholder et stort utvalg av innebygde strategier, og du kan kombinere og tilpasse dem for å generere praktisk talt alle datastrukturer du kan tenke deg.
Vanlige innebygde strategier
- Numerisk:
st.integers(min_value=0, max_value=1000)
: Genererer heltall, eventuelt innenfor et spesifikt område.st.floats(min_value=0.0, max_value=1.0, allow_nan=False, allow_infinity=False)
: Genererer flyttall, med finkornet kontroll over spesielle verdier.st.fractions()
,st.decimals()
- Tekst:
st.text(min_size=1, max_size=50)
: Genererer unicode-strenger av en viss lengde.st.text(alphabet='abcdef0123456789')
: Genererer strenger fra et spesifikt tegnsett (f.eks. for hex-koder).st.characters()
: Genererer individuelle tegn.
- Samlinger:
st.lists(st.integers(), min_size=1)
: Genererer lister der hvert element er et heltall. Legg merke til hvordan vi sender en annen strategi som et argument! Dette kalles komposisjon.st.tuples(st.text(), st.booleans())
: Genererer tupler med en fast struktur.st.sets(st.integers())
st.dictionaries(keys=st.text(), values=st.integers())
: Genererer ordbøker med spesifiserte nøkkel- og verdi-typer.
- Temporal:
st.dates()
,st.times()
,st.datetimes()
,st.timedeltas()
. Disse kan gjøres tidssonebevisste.
- Diverse:
st.booleans()
: GenerererTrue
ellerFalse
.st.just('constant_value')
: Genererer alltid den samme enkeltverdien. Nyttig for å komponere komplekse strategier.st.one_of(st.integers(), st.text())
: Genererer en verdi fra en av de medfølgende strategiene.st.none()
: Genererer bareNone
.
Kombinere og transformere strategier
Den virkelige kraften i Hypothesis kommer fra dens evne til å bygge komplekse strategier fra enklere.
Bruke .map()
.map()
-metoden lar deg ta en verdi fra en strategi og transformere den til noe annet. Dette er perfekt for å lage objekter av dine egendefinerte klasser.
# En enkel dataklasse from dataclasses import dataclass @dataclass class User: user_id: int username: str # En strategi for å generere User-objekter user_strategy = st.builds( User, user_id=st.integers(min_value=1), username=st.text(min_size=3, alphabet='abcdefghijklmnopqrstuvwxyz') ) @given(user=user_strategy) def test_user_creation(user): assert isinstance(user, User) assert user.user_id > 0 assert user.username.isalpha()
Bruke .filter()
og assume()
Noen ganger må du avvise visse genererte verdier. For eksempel kan du trenge en liste med heltall der summen ikke er null. Du kan bruke .filter()
:
st.lists(st.integers()).filter(lambda x: sum(x) != 0)
Men bruk av .filter()
kan være ineffektivt. Hvis betingelsen ofte er usann, kan Hypothesis bruke lang tid på å prøve å generere et gyldig eksempel. En bedre tilnærming er ofte å bruke assume()
inne i testfunksjonen din:
from hypothesis import assume @given(st.lists(st.integers())) def test_something_with_non_zero_sum_list(numbers): assume(sum(numbers) != 0) # ... din testlogikk her ...
assume()
forteller Hypothesis: "Hvis denne betingelsen ikke er oppfylt, bare forkast dette eksemplet og prøv et nytt." Det er en mer direkte og ofte mer ytelseseffektiv måte å begrense testdataene dine på.
Bruke st.composite()
For virkelig kompleks datagenerering der en generert verdi avhenger av en annen, er st.composite()
verktøyet du trenger. Det lar deg skrive en funksjon som tar en spesiell draw
-funksjon som et argument, som du kan bruke til å trekke verdier fra andre strategier steg for steg.
Et klassisk eksempel er å generere en liste og en gyldig indeks i den listen.
@st.composite def list_and_index(draw): # Først, trekk en ikke-tom liste my_list = draw(st.lists(st.integers(), min_size=1)) # Deretter, trekk en indeks som garantert er gyldig for den listen index = draw(st.integers(min_value=0, max_value=len(my_list) - 1)) return (my_list, index) @given(data=list_and_index()) def test_list_access(data): my_list, index = data # Denne tilgangen er garantert å være trygg på grunn av hvordan vi bygde strategien element = my_list[index] assert element is not None # En enkel påstand
Hypothesis i aksjon: Virkelige scenarier
La oss bruke disse konseptene på mer realistiske problemer som programvareutviklere møter hver dag.
Scenario 1: Testing av en dataserialiseringfunksjon
Tenk deg en funksjon som serialiserer en brukerprofil (en ordbok) til en URL-sikker streng og en annen som deserialiserer den. En viktig egenskap er at prosessen skal være perfekt reversibel.
import json import base64 def serialize_profile(data: dict) -> str: """Serialiserer en ordbok til en URL-sikker base64-streng.""" json_string = json.dumps(data) return base64.urlsafe_b64encode(json_string.encode('utf-8')).decode('utf-8') def deserialize_profile(encoded_str: str) -> dict: """Deserialiserer en streng tilbake til en ordbok.""" json_string = base64.urlsafe_b64decode(encoded_str.encode('utf-8')).decode('utf-8') return json.loads(json_string) # Nå for testen # Vi trenger en strategi som genererer JSON-kompatible ordbøker json_dictionaries = st.dictionaries( keys=st.text(), values=st.recursive(st.none() | st.booleans() | st.floats(allow_nan=False) | st.text(), lambda children: st.lists(children) | st.dictionaries(st.text(), children), max_leaves=10) ) @given(profile=json_dictionaries) def test_serialization_roundtrip(profile): """Egenskap: Deserialisering av en kodet profil skal returnere den opprinnelige profilen.""" encoded = serialize_profile(profile) decoded = deserialize_profile(encoded) assert profile == decoded
Denne enkelttesten vil hamre funksjonene våre med et massivt utvalg av data: tomme ordbøker, ordbøker med nestede lister, ordbøker med unicode-tegn, ordbøker med rare nøkler og mer. Det er langt mer grundig enn å skrive noen manuelle eksempler.
Scenario 2: Testing av en sorteringsalgoritme
La oss gå tilbake til sorteringseksemplet vårt. Her er hvordan du vil teste egenskapene vi definerte tidligere.
from collections import Counter def my_buggy_sort(numbers): # La oss introdusere en subtil feil: den slipper duplikater return sorted(list(set(numbers))) @given(st.lists(st.integers())) def test_sorting_properties(numbers): sorted_list = my_buggy_sort(numbers) # Egenskap 1: Utdataene er sortert for i in range(len(sorted_list) - 1): assert sorted_list[i] <= sorted_list[i+1] # Egenskap 2: Elementene er de samme (dette vil finne feilen) assert Counter(numbers) == Counter(sorted_list) # Egenskap 3: Funksjonen er idempotent assert my_buggy_sort(sorted_list) == sorted_list
Når du kjører denne testen, vil Hypothesis raskt finne et mislykket eksempel for Egenskap 2, for eksempel numbers=[0, 0]
. Funksjonen vår returnerer [0]
, og Counter([0, 0])
er ikke lik Counter([0])
. Krymperen vil sørge for at det mislykkede eksemplet er så enkelt som mulig, noe som gjør årsaken til feilen umiddelbart åpenbar.
Scenario 3: Statlig testing
For objekter med intern tilstand som endres over tid (som en databasetilkobling, en handlekurv eller en cache), kan det være utrolig vanskelig å finne feil. En spesifikk sekvens av operasjoner kan være nødvendig for å utløse en feil. Hypothesis tilbyr `RuleBasedStateMachine` for akkurat dette formålet.
Tenk deg et enkelt API for en minnebasert nøkkel-verdi-butikk:
class SimpleKeyValueStore: def __init__(self): self._data = {} def set(self, key, value): self._data[key] = value def get(self, key): return self._data.get(key) def delete(self, key): if key in self._data: del self._data[key] def size(self): return len(self._data)
Vi kan modellere dens oppførsel og teste den med en tilstandsmaskin:
from hypothesis.stateful import RuleBasedStateMachine, rule, Bundle class KeyValueStoreMachine(RuleBasedStateMachine): def __init__(self): super().__init__() self.model = {} self.sut = SimpleKeyValueStore() # Bundle() brukes til å sende data mellom regler keys = Bundle('keys') @rule(target=keys, key=st.text(), value=st.integers()) def set_key(self, key, value): self.model[key] = value self.sut.set(key, value) return key @rule(key=keys) def delete_key(self, key): del self.model[key] self.sut.delete(key) @rule(key=st.text()) def get_key(self, key): model_val = self.model.get(key) sut_val = self.sut.get(key) assert model_val == sut_val @rule() def check_size(self): assert len(self.model) == self.sut.size() # For å kjøre testen, underklasser du ganske enkelt fra maskinen og unittest.TestCase # I pytest kan du ganske enkelt tilordne testen til maskinklassen TestKeyValueStore = KeyValueStoreMachine.TestCase
Hypothesis vil nå utføre tilfeldige sekvenser av `set_key`, `delete_key`, `get_key` og `check_size`-operasjoner, og nådeløst prøve å finne en sekvens som får en av påstandene til å mislykkes. Den vil sjekke om henting av en slettet nøkkel oppfører seg korrekt, om størrelsen er konsistent etter flere set og deletes, og mange andre scenarier du kanskje ikke tenker å teste manuelt.
Beste praksiser og avanserte tips
- Eksempeldatabasen: Hypothesis er smart. Når den finner en feil, lagrer den det mislykkede eksemplet i en lokal katalog (
.hypothesis/
). Neste gang du kjører testene dine, vil den spille av det mislykkede eksemplet først, og gi deg umiddelbar tilbakemelding om at feilen fortsatt er til stede. Når du har fikset den, spilles ikke eksemplet lenger av. - Kontrollere testkjøring med
@settings
: Du kan kontrollere mange aspekter av testkjøringen ved hjelp av@settings
-dekoratøren. Du kan øke antall eksempler, angi en tidsfrist for hvor lenge et enkelt eksempel kan kjøre (for å fange uendelige løkker) og slå av visse helsesjekker.@settings(max_examples=500, deadline=1000) # Kjør 500 eksempler, 1 sekunds tidsfrist @given(...) ...
- Reproduksjon av feil: Hver Hypothesis-kjøring skriver ut en seed-verdi (f.eks.
@reproduce_failure('version', 'seed')
). Hvis en CI-server finner en feil som du ikke kan reprodusere lokalt, kan du bruke denne dekoratøren med den medfølgende seed-en for å tvinge Hypothesis til å kjøre nøyaktig den samme sekvensen av eksempler. - Integrering med CI/CD: Hypothesis er perfekt for enhver kontinuerlig integrasjons-pipeline. Dens evne til å finne obskure feil før de når produksjon gjør det til et uvurderlig sikkerhetsnett.
Tankesettet: Tenke i egenskaper
Å ta i bruk Hypothesis er mer enn bare å lære et nytt bibliotek; det handler om å omfavne en ny måte å tenke på kodens korrekthet. I stedet for å spørre: "Hvilke inndata skal jeg teste?", begynner du å spørre: "Hva er de universelle sannhetene om denne koden?"
Her er noen spørsmål som kan veilede deg når du prøver å identifisere egenskaper:
- Finnes det en omvendt operasjon? (f.eks. serialisere/deserialisere, kryptere/dekryptere, komprimere/dekomprimere). Egenskapen er at utførelse av operasjonen og dens omvendte skal gi den opprinnelige inndataen.
- Er operasjonen idempotent? (f.eks.
abs(abs(x)) == abs(x)
). Å bruke funksjonen mer enn én gang skal gi samme resultat som å bruke den én gang. - Finnes det en annen, enklere måte å beregne det samme resultatet på? Du kan teste at din komplekse, optimaliserte funksjon produserer de samme utdataene som en enkel, åpenbart korrekt versjon (f.eks. teste din fancy sortering mot Pythons innebygde
sorted()
). - Hva skal alltid være sant om utdataene? (f.eks. utdataene fra en `find_prime_factors`-funksjon skal bare inneholde primtall, og produktet av dem skal være lik inndataene).
- Hvordan endres tilstanden? (For statlig testing) Hvilke invarianter må opprettholdes etter enhver gyldig operasjon? (f.eks. Antall varer i en handlekurv kan aldri være negativt).
Konklusjon: Et nytt nivå av selvtillit
Egenskapsbasert testing med Hypothesis erstatter ikke eksempelbasert testing. Du trenger fortsatt spesifikke, håndskrevne tester for kritisk forretningslogikk og godt forståtte krav (f.eks. "En bruker fra land X må se pris Y").
Det Hypothesis gir er en kraftig, automatisert måte å utforske oppførselen til koden din og beskytte mot uforutsette edge cases. Det fungerer som en utrettelig partner, og genererer tusenvis av tester som er mer mangfoldige og lumske enn noe menneske realistisk sett kan skrive. Ved å definere de grunnleggende egenskapene til koden din, oppretter du en robust spesifikasjon som Hypothesis kan teste mot, noe som gir deg et nytt nivå av selvtillit i programvaren din.
Neste gang du skriver en funksjon, ta deg tid til å tenke utover eksemplene. Spør deg selv: "Hva er reglene? Hva må alltid være sant?" La deretter Hypothesis gjøre det harde arbeidet med å prøve å bryte dem. Du vil bli overrasket over hva den finner, og koden din vil bli bedre av det.